React 内で JS のクラスインスタンスを用いる
たまに、 #React 内で JS のクラスを new して使いたい(かつ保持したい)ことがある。 EventEmitter とか外部の UI ライブラリ辺りが定番かと思う(カルーセルやスライダーのライブラリを自分でコンポーネントにラップした経験は数しれず)。
最近の JS では pure object + 純粋関数という #設計 を取るケースも増えたが、たとえば DOM API に対して副作用を起こすミュータブルな処理はクラスで書いたほうが自然なことも多い(ライブラリが new Slider のような形式を取るのもそれが理由だろう)。 そんなわけでミュータブルなクラスを React と合わせたいケースがあるものの、その実装知見が世の中には少ないようだ。
自分がふだん hooks + JS クラスで設計する時のパターンをメモしていく。ただ、以下の内容は今後 Concurrent Mode が来たら時代遅れになるかもしれない(副作用に対する考え方が変わるので。ただ著者は Concurrent Mode については詳しくないです)。
TL;DR
だいたい以下のパターンに収まる。
code:typescript
export function useXxx<E extends Element = HTMLElement>(onChange: OnChange) {
const ref = useRef<E>(null)
useEffect(() => {
const hoge = new Hoge({ el: ref.current!, onChange })
return () => hoge.destroy()
return ref
}
基本構造はデストラクタ風に、useEffect で初期化と破棄( ここでいう破棄とは大体の場合 Element#removeEventListener )を行う。コンポーネントのステートとのやり取りは、コンストラクタで渡されたイベントハンドラのみを通じて行う。たまに EventEmitter のように、コンストラクタではなく .on() でセットするケースもあるが、これも先の派生系として見た方がわかりやすい。
code:typescript
useEffect(() => {
const hoge = new Hoge(ref.current!)
hoge.on('change', onChange)
return () => hoge.off('change', onChange)
ミュータブルさと向き合う
前提として、こういう設計を取りたいケースのクラスは基本的にミュータブルなものとする。
なぜならそもそもイミュータブルなオブジェクトは pure object で済ませばよく、じゃあ最初からクラスを使わなければ良いよねという話になるからだ。
どうしてもクラスを使いたいと思っている時点で何らかのミュータブルな要件と上手くやる必要があるはずで、無いとしたらクラスにするのが間違っている。Immutable.js?そんな子は私は知りませんね。
冒頭であげた DOM のラッパーはこの典型例だと思う。たとえばコンストラクタで受け取った HTMLInputElement を良い感じのスライダーにしたい。Slider はドラッグのたびに現在の値を emit するが、ドラッグ開始時点の値はミュータブルなインスタンス変数が覚えている、とか。
ともかくここで言いたいのは、この手の設計を汎用化したくなったときに JS class を選択肢にするのは間違いではないということだ。
基本プライベート、窓口はイベントハンドラのみ
かと言ってしかし、当のミュータブルな状態は外から触れられるべきではない。
先の例でいうと、ドラッグ開始位置をコンポーネントが知りたいようなケースでも、それは onChange の引数などを通じてのみ知られるべきで、それ以外の経路を通すべきではない。
コンポーネントはインスタンスに渡すメソッドを通じて、たとえば setState したり、store に何かを dispatch() する。このスタンスを基本的には崩さない方が良い。
そして、それを徹底して守るためには、インスタンスは useEffect のローカル変数にしておくほうが良い。
下手に const ref = useRef(new Hoge(...)) をすると、外から触り放題になってしまう。
どうしても外からメソッドを叩きたいとき
しかし、現実は非情なので外からインスタンスのメソッドを叩かざるを得ないケースが出てくる。
たとえばカルーセルをコンポーネントにラップしたいとする。
基本は onChange をカルーセルクラスに渡せば良いのだが、離れたところにあるボタンを押すと carousel.goTo(3) が叩ける必要があるなどだ。
こういうケースだと、carousel を ref などに格納しておく必要が出てくる
code:typescript
const ref = useRef<HTMLDivElement>(null)
const carousel = useRef<Siema | null>(null)
useEffect(() => {
carousel.current = new Siema({
selector: ref.current,
loop: true,
onChange(this: Siema) {
setCurrentIndex(this.currentSlide)
},
})
return () => carousel.current?.destroy(true)
}, [])
code:typescript
const onSelect = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const next = Number(e.currentTarget.dataset.index!)
setCurrentIndex(next)
carousel.current?.goTo(next)
}, [])
さらにこれを親コンポーネントから叩きたい場合は、useImperativeHandle を適切に使うしかないので、頑張ってやると良い。
余談: 連続で new と destroy を繰り返すのを防ぐ
ここまでのパターンでだいたいは上手くいくが、さらにめちゃめちゃ大変なケースがある。
たとえばある要素を draggable にするクラスが onMove メソッドを持っている。だが onMove にわたす関数はコンポーネントの state に依存していて、かつその state はドラッグすると変化するとしよう。
code:typescript
const onMove = useCallback(() => {
...
useEffect(() => {
const draggable = new Draggable(ref.current!, onMove)
return () => draggable.destroy()
こうすると何が起こるかと言うと、要素を 1px ドラッグするたびに useEffect が走り、new Draggable が1秒にン十回走るということが起こる。これは明らかに破綻する。
クラスコンポーネント時代はこういうことは起こらなかった。なぜならコンポーネントのメソッドは常に同じインスタンスから生えていて、参照が変わることはない。だからステートフルなメソッドを雑に書いても結構何とかなってしまっていた。
code:typescript
componentDidMount() {
this.draggable = new Draggable(this.ref.current!, this.onMove)
}
onMove = (x: number, y: number) => {
if (this.state.positionX) {
...
}
}
Function Component で参照を変えずにステートフルなロジックを書く方法として、実は useReducer がある。
code:typescript
const onMove = useCallback((nextX: number, nextY: number) => {
dispatch({
type: 'layer/dragMoved',
payload: { nextX, nextY }
})
}, [])
useEffect(() => {
const draggable = new Draggable(ref.current!, onMove)
return () => draggable.destroy()
これで reducer の中身がいかにステートフルになっていようと参照が変わらないので、連続で実行しても安心ということになる。
まとめ
React において、ミュータブルな処理を隔離する目的で JS のクラスを使うことができる
基本的にインスタンスは useEffect の中に閉じて、イベントハンドラだけを通じて外界とやり取りする
メソッドがコンポーネントから叩ける必要がある場合、useRef を用いる
イベントハンドラをものすごく連続的に実行する必要がある場合、useReducer と合わせると良い